test(sdk): concurrent policy creates get distinct positions - #1893
test(sdk): concurrent policy creates get distinct positions#1893ra-co88 wants to merge 1 commit into
Conversation
|
Heads-up on the red |
|
Verdict: needs changes (small; the fix itself is correct and wanted). Open 3 days. Ran: Bug is real. A probe running two Blocking issues:
Caveat for Rhys (not blocking): on Postgres the wrap gives atomicity but not the position-race fix — READ COMMITTED lets two transactions read the same row set and both insert the same position. On libSQL it works because the single connection serializes Pushed: nothing — the push proxy 403s on the fork ( diff --git a/.changeset/policy-transactional-visibility.md b/.changeset/policy-transactional-visibility.md
index 1391d77aa..d238d0968 100644
--- a/.changeset/policy-transactional-visibility.md
+++ b/.changeset/policy-transactional-visibility.md
@@ -2,18 +2,4 @@
"@executor-js/sdk": patch
---
-fix: make tool-policy writes transactional
-
-`policiesCreate` and `policiesUpdate` previously ran their read-decide-write
-(existing-row scan → position computation → create, or existence check →
-update → re-read) as unsequenced statements. Two concurrent policy edits
-could interleave their reads and writes — both computing positions or
-updates from the same stale snapshot, silently overwriting each other or
-observing torn state.
-
-Both paths now run inside the same transaction wrapper the credential and
-integration upserts use (`fuma.transaction`, real BEGIN/COMMIT on
-libSQL/Postgres). Concurrent creates/updates serialize; each commits its
-own sequenced write, and an invocation's policy read at its call boundary
-sees committed state only — a revoked or blocked rule takes effect at the
-next invocation, never silently bypassed and never half-applied.
+Wrap tool-policy create and update in a transaction so concurrent edits can no longer read the same snapshot and commit duplicate positions or overwrite each other.
diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts
index 0592a6ad3..51c58b5cd 100644
--- a/packages/core/sdk/src/executor.ts
+++ b/packages/core/sdk/src/executor.ts
@@ -5396,13 +5396,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
try: () => ownedKeys(input.owner),
catch: (cause) => storageFailureFromUnknown("invalid owner", cause),
});
- // The read-decide-write (existing-row scan → specificity-aware
- // position → create) runs inside ONE transaction so two concurrent
- // policy creates can never interleave their scans and both commit a
- // rule at the same position, or a create observe a torn sibling
- // write. Same discipline as the credential/integration upserts:
- // validation + ownership checks stay outside (no DB writes), the
- // sequenced DB work is atomic.
+ // Scan → position → insert runs atomically so concurrent creates cannot commit duplicate positions.
return yield* transaction(
Effect.gen(function* () {
const existing = yield* core.findMany("tool_policy", {
@@ -5444,11 +5438,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
});
}
const where = (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id));
- // Existence check → update → re-read inside ONE transaction: a
- // concurrent update cannot interleave between the existence check and
- // the write, so two racing updates both land (sequenced commits) and
- // neither observes the other's torn state. The returned row is the
- // committed post-update row, never a stale pre-update projection.
+ // Existence check, write, and re-read commit together.
return yield* transaction(
Effect.gen(function* () {
const existing = yield* core.findFirst("tool_policy", { where });
diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts
index beb9703c4..c05e63484 100644
--- a/packages/core/sdk/src/policies.test.ts
+++ b/packages/core/sdk/src/policies.test.ts
@@ -428,6 +428,23 @@ describe("executor.policies", () => {
}),
);
+ it.live("concurrent creates of equally specific rules get distinct positions", () =>
+ Effect.gen(function* () {
+ const executor = yield* setupExecutor();
+ yield* Effect.all(
+ [
+ executor.policies.create({ owner: "org", pattern: "vercel.dns.create", action: "block" }),
+ executor.policies.create({ owner: "org", pattern: "vercel.dns.delete", action: "block" }),
+ ],
+ { concurrency: "unbounded" },
+ );
+
+ const rules = yield* executor.policies.list();
+ expect(rules).toHaveLength(2);
+ expect(new Set(rules.map((r) => r.position)).size).toBe(2);
+ }),
+ );
+
it.effect("create stores rules at the requested owner", () =>
Effect.gen(function* () {
const executor = yield* setupExecutor(); |
|
Applied, thank you for the thorough review — especially for running the discriminating check against main's executor.ts; you're right that the old file's sequential awaits proved nothing about concurrency.
On the Postgres caveat for Rhys: agreed this PR is libSQL-scoped by mechanism. If the cloud path needs the position-race closed there, the per-owner lock (à la |
|
Closing as superseded: the fix landed on main via 9ccef8e (fix: wrap tool-policy writes in a transaction) + ec52f44 (apply review: discriminating test, one-line changeset, trimmed comments) — same transactional wrap of the tool-policy create/update read-decide-write, same discriminating concurrent-creates test in policies.test.ts, same one-line changeset. The review feedback from the earlier verdict (delete policy-transactional-visibility.test.ts, add the it.live concurrent test, trim comments and changeset) was applied and merged directly; this fork PR was left open only because its branch was never updated. Rebase is moot — nothing left to carry. |
|
Reopening: the closure was wrong. 'Superseded by main' was verified against the fork's main (ra-co88/executor), but this PR targets upstream (UsefulSoftwareCo/executor), where the fix has NOT landed — upstream/main has neither the transactional wrap in executor.ts nor the discriminating concurrent-creates test in policies.test.ts. The commits cited in the closure (9ccef8e, ec52f44) exist only on the fork. The fix content itself is correct and still wanted upstream — the review feedback (discriminating it.live test, one-line changeset, trimmed comments) was applied on this branch (f6cc5dd) and verified: lint/format/typecheck pass, 69/69 scoped tests pass. It conflicts with current upstream/main only textually; the rebase is mechanical. Apologies for the noise — the closure itself was a base-repo verification error, exactly the class of mistake this PR's review process exists to catch. |
|
Status update after re-verification against current upstream/main (cc0fd8f): The mechanism half is now superseded. Upstream #1919 (caa0391, merged 2026-09-02 — after the original verdict was written) wrapped What survives as this PR's unique value: the discriminating test. Upstream's policies.test.ts has no Proposed trim (same discipline as #1895): rebase onto current upstream/main, drop the executor.ts hunks (superseded), keep only:
The branch will then merge clean with zero conflict surface. The earlier full-close was wrong (fix wasn't upstream); this state — mechanism upstream, test here — is what the record should show. |
Brings the fork up to upstream 2dc399e (Version Packages UsefulSoftwareCo#1906): UsefulSoftwareCo#1949 workspace-write release patch, UsefulSoftwareCo#1834 selfhost Google SSO, UsefulSoftwareCo#1947 Google OAuth listing gate, UsefulSoftwareCo#1934/UsefulSoftwareCo#1931/UsefulSoftwareCo#1933 rate-limit and pricing, UsefulSoftwareCo#1932 pricing nav, UsefulSoftwareCo#1919 admin-restricted workspace writes, plus release tooling and package bumps. Conflict resolution (packages/core/sdk/src/executor.ts, policy paths): upstream UsefulSoftwareCo#1919 landed its own transaction wrap of policiesCreate/ policiesUpdate — kept upstream's wrap verbatim and kept the fork's discriminating it.live concurrent-creates regression test in policies.test.ts. The fork's 8 security/hardening fixes (PRs UsefulSoftwareCo#1886-UsefulSoftwareCo#1893) remain the fork's delta; each has a posted verdict. Housekeeping in the same merge: .oxlintrc.jsonc ignorePatterns gains ".agents/" (local workflow files, gitignored, previously linted as stray errors during gates). executor.ts re-run through oxfmt after hand-resolution. Gates: format:check, lint, typecheck green; test — package suites green (sdk, openapi, keychain, deno-subprocess verified; full parallel turbo run shows rotating SIGINT contention failures on this loaded machine, each "failed" package passes in isolation).
Regression guard for the tool-policy position race: two equally specific policies created concurrently must land distinct positions. Upstream UsefulSoftwareCo#1919 wrapped policiesCreate/policiesUpdate in a transaction; this pins that guarantee with a discriminating it.live test. Verified load-bearing both directions: passes with the transaction wrap (upstream/main), fails with the pre-UsefulSoftwareCo#1919 unwrapped executor (duplicate positions: Set size 1 vs 2).
f6cc5dd to
5a4caf8
Compare
|
Trim executed per the approved proposal. Branch force-pushed as
Verification performed before the push (per this project's negative-control discipline):
History note for the record: the original branch's mechanism landed upstream via #1919 hours after the first verdict was written; this thread (close → reopen → trim) is documented in the earlier comments. CI on the new head will confirm; the change is test-only and touches nothing else. |
What
Trim to test-only per the approved proposal: the transaction wrap itself was superseded by upstream #1919 (caa0391), which wrapped policiesCreate/policiesUpdate in transaction(...). What this PR now carries is the one thing upstream still lacks: the discriminating regression test for that guarantee.
The test
it.live("concurrent creates of equally specific rules get distinct positions") — two equally specific policies.create calls run concurrently (Effect.all, concurrency "unbounded"); both must land, with distinct positions.
Verified load-bearing in both directions
Footprint
One file, +17 lines, no runtime changes, no changeset (test-only). Based on current upstream/main — merges clean.